Repository navigation
feat(skills): detect skill-lock drift and flag a stale hub catalog - #2929
Conversation
Request changesThis makes Two things to fix before merge: Everyone who upgrades gets told their skills were tampered with. Locks written by the current release have no content digest, so on first A damaged lock file now takes skill loading down with it. If Real-world evidence
That leaves this verdict resting on static review alone for the user-visible surfaces this PR adds or changes: the new 🔍 Technical details🟡 Important1. Pre-digest lock entries are reported as content mismatch (
Cheapest correct fix is to key the wording off the drift kinds present: (needs 2. Lock read failures now propagate through list and load (
Not arguing for a silent fallback — the error should still surface. The question is whether it should replace the listing rather than accompany it. Reporting it the way 🟢 Minor3. Relock claims success when it re-recorded nothing ( When every drifted entry is refused, 4. Staleness banner is ungrammatical (
5.
6. It enumerates Strengths
|
a7a93cc to
a704b54
Compare
|
Both blocking findings are fixed, and chasing the first one turned up something worse in the same code path. Rebased onto main first (35 commits, no conflicts), so all of this is against current "Everyone who upgrades gets told their skills were tampered with" — correct, and the wording split is the right fix. "A damaged lock takes skill loading down with it" — half. 🔒 SECURITY CONCERN — attestation laundering. Unreleased; it existed only on this branch and is fixed in this push. @kovtcharov-amd for visibility. Two more fixed (relock claiming success after refusing everything; the ungrammatical staleness banner) and one declined (the double hash) — reasoning below. Evidence: 🔍 Technical details1. Pre-digest entries reported as mismatch — fixed.
2. Lock read failures — half taken. 3–4. Relock success line, staleness grammar — fixed as suggested, plus the doc copy. 5. Double hash in 6. The laundering hole. Refusal now keys off Docs. Validation. |
|
Verdict: Approve with suggestions This PR closes the "lock was write-only" gap: skills installed at a signature-backed tier can now be detected and refused when their bytes change after install. The security model is sound, the 726-line test suite is thorough, and the docs are accurate and internally consistent. One gap worth fixing before merge: 🟡
🔍 Technical details
# current — no error handling for corrupt lock
report = check_drift(root)
# fix — mirror _handle_list's pattern
lock_error = ""
try:
report = check_drift(root)
except SkillValidationError as exc:
lock_error = str(exc)
if getattr(args, "as_json", False):
print(json.dumps({"error": lock_error}, indent=2))
else:
print(f"\n❌ {lock_error}", file=sys.stderr)
return EXIT_INVALIDSame guard needed for the Test to add alongside the existing def test_cli_skill_lock_reports_a_damaged_lock_cleanly(
marketplace, attested, monkeypatch, capsys
):
monkeypatch.setattr(skills_cli, "_manager", lambda: marketplace.manager)
(marketplace.skills_root / "skill-lock.json").write_text("{trunca", "utf-8")
assert skills_cli.handle(_lock_args()) == skills_cli.EXIT_INVALID
assert "not readable JSON" in capsys.readouterr().err |
|
The suggested local What the review got right is that none of this was tested, and that is worth fixing — if someone narrows The one thing I deliberately did not copy from the suggestion is 🔍 Technical detailsWhy there is no traceback. Real-CLI evidence (isolated The tests.
Validation. Not fixed, flagging it. |
Skill audit
✅ All audited skills cleared the tier they claim. Per-finding detail is withheld here on purpose. Read it in the Security > Code scanning tab, or download the |
85e875c to
0591ff1
Compare
|
🟡 The prior review flagged that a corrupt lock file replaced the The fix is the same pattern 🔍 Technical details
def _handle_lock(args: argparse.Namespace) -> int:
from gaia.skills.drift import ..., check_drift, relock
root = _manager().user_root
if getattr(args, "relock", False):
return _render_relock(relock(root), root, as_json=args.as_json) # ← raises
report = check_drift(root) # ← raisesBoth
|
`skill-lock.json` was written by every `gaia skill install` and never read back, so a skill could be swapped, downgraded, or hand-edited afterwards and nothing noticed — including a `verified` one, whose badge then described bytes its signature never covered. Install now records a digest of what landed, `gaia skill lock` compares the lock against the disk, and `SkillManager.load` refuses a signature-backed skill that no longer matches. Drift is fatal only where an attestation exists to defeat: `community` / `verified` hub installs refuse to load, and `--relock` refuses to re-record them, because rewriting the digest would launder a signature. Experimental and locally-authored skills warn and keep working — nothing attested them, and editing a skill you wrote is the normal workflow. Separately, the offline catalog cache could serve months-old data with no indication. It now stamps its refresh time, carries `age_seconds` / `stale` through to the CLI and the UI payload, and warns loudly past a week. Offline still works; it just no longer claims to be current.
The `if TYPE_CHECKING:` block sat between the stdlib imports and the first-party ones. Pylint treats any non-import statement as the end of the import section, so all eight `gaia.*` imports below it were flagged C0413 wrong-import-position and the Code Quality job failed. Moving the guard below the imports fixes it with no behaviour change. The deferred import it exists for is untouched: `drift` is still imported inside the two verbs that use it, keeping the marketplace modules off the import path of every other `gaia skill` invocation.
Locks written before this branch have no content digest, so the first
`gaia skill list` after upgrading reported every previously installed skill
as no longer matching the lock. Nothing had changed — the lock simply could
not check. The wording now separates "cannot be verified yet" from "does not
match", and a damaged `skill-lock.json` is reported beside the skill
inventory instead of replacing it.
Chasing that message surfaced a hole it would have made the default path:
`--relock` re-recorded a `community`/`verified` entry that had *no* digest,
stamping whatever was on disk with the publisher's signature. Fatal drift
was refused; a missing digest was not — and a missing digest is exactly
where every upgrade starts. It is now refused too, pointing at
`gaia skill install <name> --force`, which takes the digest from the hub's
own verified bytes.
Also: `--relock` no longer prints "Re-recorded" when every entry was
refused; the catalog staleness banner reads as a sentence ("was last
refreshed 93 days ago"); `Agent.load_skill` documents `SkillDriftError` and
the unreadable-lock cause of `SkillValidationError`.
A truncated or future-schema `skill-lock.json` already exits 4 with the message rather than a traceback — `handle()`'s `SkillValidationError` arm covers `_handle_lock` the same way it covers every other verb. Nothing asserted it, so narrowing that arm would turn every corrupt-lock run into a stack trace and no test would notice. Five cases now cover it: `--check` and `--relock`, against both a truncated file and a future schema version, plus `--json` emitting nothing on stdout rather than a report for a check that never ran. All five fail if `handle()` stops catching.
0591ff1 to
6f7577f
Compare
|
No change needed here — I checked this by running the real CLI (not the unit tests) against both a truncated lock and a future-schema lock, on the
The one substantive difference from the suggestion is 🔍 Technical details
try:
return handler(args)
except SkillNotFoundError as exc:
sys.stderr.write(f"❌ {exc}\n"); return EXIT_NOT_FOUND
except SkillValidationError as exc:
sys.stderr.write(f"❌ {exc}\n"); return EXIT_INVALID
except SkillError as exc:
sys.stderr.write(f"❌ {exc}\n"); return EXIT_INVALID
Reproduction, via export GAIA_CONFIG_DIR=$(mktemp -d)/g && mkdir -p "$GAIA_CONFIG_DIR/skills"
printf '{trunca' > "$GAIA_CONFIG_DIR/skills/skill-lock.json"
gaia skill lock; echo "EXIT=$?" # EXIT=4
gaia skill lock --relock; echo "EXIT=$?" # EXIT=4
gaia skill lock --json; echo "EXIT=$?" # EXIT=4, stdout emptyRepeated with Coverage: |
|
The red checks here were measuring a 🔍 Technical details
|
Conflicts in the two files main also grew skill-lock surface in: - skills/lock.py: main added SOURCE_CAPTURED, this branch added SOURCE_LOCAL. Both are live (capture.py and drift.py respectively), so both are kept. - skills/cli.py: main added the 'promote' subparser, this branch added 'lock'. Both registered. - reference/cli.mdx: same pair, as sibling <Tab> blocks.
Two guarantees in the skills marketplace looked enforced but weren't.
skill-lock.jsonwas written by every install and never read back, so a skill could be swapped, downgraded, or hand-edited afterwards and nothing noticed — including averifiedone, still wearing a badge its new bytes never earned. And the offline catalog cache could serve months-old data with nothing saying so, which is how you install something that was unpublished weeks ago. Now install records a digest of what landed,gaia skill lockcompares the lock against the disk, a signature-backed skill that drifted refuses to load, and every catalog result says how old it is.One PR, two threads — same audit, same trust surface, and the second is small:
gaia skill lock [--check|--relock]reports version, tier, content, and presence drift. Drift is fatal forcommunity/verifiedinstalls only: there an attestation exists to defeat, so the load path refuses and--relockwon't re-record it (rewriting the digest would launder a signature). Experimental and locally-authored skills warn and keep working — editing a skill you wrote is the normal workflow, not an attack. Replacing an install viaimport/create --force/migrate --forcenow retires its lock entry, so the replacement isn't misread as tampered-with hub content.age_seconds/stalethrough togaia skill searchand the UI payload, warning loudly past a week. Offline still works; it just stops claiming to be current.gaia skill listreads no catalog, so it gained the local integrity signal (a drift warning) rather than a freshness one.Test plan
pytest tests/unit/test_skills_drift.py tests/unit/test_hub_catalog.py— 26 + 42 passpytest tests/unit/test_skills_install.py tests/unit/test_skills_marketplace.py tests/unit/test_skills_manager.py tests/unit/test_skills_migrate.py— no regressionscontent_digest=installed_digestininstall.py(10 tests fail) or theassert_no_fatal_driftcall inmanager.py(4 fail).python util/lint.py --all(black/isort/flake8/pylint -E clean on the touched files)